﻿//-----------------------------------------------------------------------
// <copyright file="BuildReleaseNotes.cs">(c). All other rights reserved.</copyright>
//-----------------------------------------------------------------------
namespace TfsBuildExtensions.Activities.TeamFoundationServer
{
    using System;
    using System.Activities;
    using System.Collections.Generic;
    using System.Globalization;
    using System.IO;
    using System.Linq;
    using Microsoft.TeamFoundation.Build.Client;
    using Microsoft.TeamFoundation.Build.Common;
    using Microsoft.TeamFoundation.Build.Workflow;
    using Microsoft.TeamFoundation.Client;
    using Microsoft.TeamFoundation.TestManagement.Client;
    using Microsoft.TeamFoundation.VersionControl.Client;
    using Microsoft.TeamFoundation.WorkItemTracking.Client;
    using WordDocumentGenerator.Library;

    /// <summary>
    /// Generates Build Release Notes for the current build.
    /// </summary>
    [BuildActivity(HostEnvironmentOption.All)]
    public sealed class BuildReleaseNotes : BaseCodeActivity
    {
        TfsTeamProjectCollection tfs;
        VersionControlServer versionControlServer;
        IBuildServer buildServer;
        TestManagementService testManagementService;

        /// <summary>
        /// Gets or sets the name of the build quality filter. 
        /// Builds must have this build quality and they must have succeeded!.
        /// </summary>
        [RequiredArgument]
        public InArgument<string> BuildQualityFilter { get; set; }

        /// <summary>
        /// Gets or sets the targetpath, i.e. the folder that contains the solution(s).
        /// </summary>
        [RequiredArgument]
        public InArgument<string> TargetPath { get; set; }

        /// <summary>
        /// Gets or sets the sources directory.
        /// </summary>
        [RequiredArgument]
        public InArgument<string> SourcesDirectory { get; set; }

        /// <summary>
        /// Gets or sets the binaries directory.
        /// </summary>
        [RequiredArgument]
        public InArgument<string> BinariesDirectory { get; set; }

        /// <summary>
        /// Gets or sets the filename for the Build Release Notes MSWord document.
        /// </summary>
        [RequiredArgument]
        public InArgument<string> BuildReleaseNotesFileName { get; set; }

        /// <summary>
        /// Gets or sets the MSWord build release notes template filename.
        /// </summary>
        [RequiredArgument]
        public InArgument<string> TemplateFileName { get; set; }

        /// <summary>
        /// Generates the build release notes.
        /// </summary>
        protected override void InternalExecute()
        {
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "InternalExecute enter."), BuildMessageImportance.Low);
            var buildReleaseNotesBuildDetail = this.ActivityContext.GetExtension<IBuildDetail>();

            if (buildReleaseNotesBuildDetail == null) throw new InvalidOperationException("buildReleaseNotesBuildDetail");

            var targetPath = TargetPath.Get(this.ActivityContext);
            if (string.IsNullOrWhiteSpace(targetPath))
            {
                this.LogBuildWarning(string.Format(CultureInfo.CurrentCulture, "The TargetPath is not specified, Build Release Notes will not be generated."));
                return;
            }

            var quality = BuildQualityFilter.Get(this.ActivityContext);
            if (string.IsNullOrWhiteSpace(quality))
            {
                this.LogBuildWarning(string.Format(CultureInfo.CurrentCulture, "The Build Quality filter is not specified, Build Release Notes will not be generated."));
                return;
            }

            var buildReleaseNotesFileName = BuildReleaseNotesFileName.Get(this.ActivityContext);
            if (string.IsNullOrWhiteSpace(buildReleaseNotesFileName))
            {
                this.LogBuildWarning(string.Format(CultureInfo.CurrentCulture, "The Build Release Notes filename is not specified, Build Release Notes will not be generated."));
                return;
            }

            if (string.IsNullOrWhiteSpace(buildReleaseNotesBuildDetail.ProcessParameters))
            {
                this.LogBuildWarning(string.Format(CultureInfo.CurrentCulture, "ProcessParameters is empty, Build Release Notes will not be generated."));
                return;
            }

            this.buildServer = buildReleaseNotesBuildDetail.BuildServer;
            tfs = buildReleaseNotesBuildDetail.BuildServer.TeamProjectCollection;
            this.versionControlServer = tfs.GetService<VersionControlServer>();
            this.testManagementService = tfs.GetService<TestManagementService>();

            var dictionary = WorkflowHelpers.DeserializeProcessParameters(buildReleaseNotesBuildDetail.ProcessParameters);
            var buildDetail = this.buildServer.GetBuild(new Uri(dictionary["BuildUri"].ToString()));
            var projectNodes = InformationNodeConverters.GetBuildProjectNodes(buildDetail).Distinct(new IBuildProjectNodeComparer()).ToArray();
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "Found {0} projects.", projectNodes.Count()), BuildMessageImportance.Low);

            var configurationSummaries = InformationNodeConverters.GetConfigurationSummaries(buildDetail).ToArray();
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "Found {0} configs.", configurationSummaries.Count()), BuildMessageImportance.Low);

            // Get the associated changeset from the build.
            var changesetList = new SortedDictionary<int, Changeset>();

            // Find the latest build with the given build quality filter.
            var latestBuild = GetLatestBuildWithQuality(buildDetail, quality);
            if (latestBuild == null)
            {
                this.LogBuildWarning(string.Format(CultureInfo.CurrentCulture, "No previous builds were found, setting latest build to current build."));
                latestBuild = buildDetail;
            }
            else
            {
                GetChangesetsFromBuildRange(buildDetail, latestBuild, changesetList);
            }

            // Get the changesets associated to the build.
            var changesetFrom = VersionSpec.Parse(latestBuild.SourceGetVersion, null).SingleOrDefault() as ChangesetVersionSpec;
            var changesetTo = VersionSpec.Parse(buildDetail.SourceGetVersion, null).SingleOrDefault() as ChangesetVersionSpec;
            var targetPathList = new List<string>();

            // Find changesets from the merges to this targetpath.
            GetMergeDetails(targetPath, 0, changesetFrom, changesetTo, changesetList, targetPathList);

            // Get the workitems associated to the changesets.
            var workItems = GetWorkItems(changesetList);

            // Get the test case results.
            var testCaseResults = GetTestCaseResults(buildDetail);

            var changesets = changesetList.Values.Reverse().ToArray();
            
            var documentAbstract = new DocumentAbstract(buildDetail, latestBuild, changesets, workItems);

            // Generate the document.
            var context = new BuildDataContext()
            {
                BuildDetail = buildDetail,
                BuildProjectNodes = projectNodes,
                ConfigurationSummaries = configurationSummaries,
                Changesets = changesets,
                WorkItems = workItems,
                TestCaseResults = testCaseResults,
                LatestBuildDetail = latestBuild,
                Abstract = documentAbstract
            };

            var template = this.ActivityContext.GetValue<string>(TemplateFileName);
            var sourcesDirectory = SourcesDirectory.Get(this.ActivityContext);
            template = TryLocateFile(template, sourcesDirectory, targetPath, versionControlServer);
            DocumentGenerationInfo generationInfo = GetDocumentGenerationInfo("BuildReleaseNotesDocumentGenerator", "1.0", context, template, false);
            var buildReleaseNotesGenerator = new BuildReleaseNotesDocumentGenerator(generationInfo);
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "Generating document..."), BuildMessageImportance.Low);
            byte[] result = result = buildReleaseNotesGenerator.GenerateDocument();
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "Generating document done."), BuildMessageImportance.Low);

            // Write the generated BRN.
            var binariesDirectory = BinariesDirectory.Get(this.ActivityContext);
            var fileName = Path.Combine(binariesDirectory, buildReleaseNotesFileName);
            File.WriteAllBytes(fileName, result);
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "Build Release Notes written to '{0}'.", fileName), BuildMessageImportance.Low);

            // Delete the template file if created as temporary file.
            CleanTempFile(template);
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "InternalExecute enter."), BuildMessageImportance.Low);
        }

        /// <summary>
        /// Finds the associated changesets between two builds.
        /// </summary>
        /// <param name="buildDetail"></param>
        /// <param name="latestBuild"></param>
        /// <param name="changesetList"></param>
        private void GetChangesetsFromBuildRange(IBuildDetail buildDetail, IBuildDetail latestBuild, SortedDictionary<int, Changeset> changesetList)
        {
            const int MaxBuildsToGet = 100;
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "GetChangesetsFromBuildRange enter."), BuildMessageImportance.Low);
            var spec = this.buildServer.CreateBuildDetailSpec(buildDetail.BuildDefinition);
            spec.InformationTypes = new string[] { InformationTypes.AssociatedChangeset };
            spec.QueryDeletedOption = QueryDeletedOption.IncludeDeleted;
            spec.MaxFinishTime = (buildDetail.FinishTime == DateTime.MinValue || buildDetail.FinishTime == DateTime.MaxValue) ? buildDetail.StartTime.AddSeconds(-1) : buildDetail.FinishTime.AddSeconds(-1);
            spec.MinFinishTime = latestBuild.FinishTime.AddSeconds(1);
            spec.Status = BuildStatus.Failed | BuildStatus.PartiallySucceeded | BuildStatus.Stopped | BuildStatus.Succeeded;
            spec.QueryOptions = QueryOptions.None;
            spec.MaxBuildsPerDefinition = MaxBuildsToGet;

            var result = this.buildServer.QueryBuilds(spec);
            if (result.Builds.Count() == MaxBuildsToGet)
            {
                this.LogBuildWarning(string.Format(CultureInfo.CurrentCulture, "The maximum number of builds ({0}) where found. More builds could exist, but I choose only to get this max number of builds.", result.Builds.Count()));
            }
            else
            {
                this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "Found {0} builds", result.Builds.Count()), BuildMessageImportance.Low);
            }
            foreach (var build in result.Builds)
            {
                var changesetSummary = InformationNodeConverters.GetAssociatedChangesets(build);
                this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "Found {0} changesets.", changesetSummary.Count), BuildMessageImportance.Low);
                foreach (var summary in changesetSummary)
                {
                    changesetList.TryAdd(summary.ChangesetId, this.versionControlServer.GetChangeset(summary.ChangesetId));
                }
            }
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "GetChangesetsFromBuildRange exit."), BuildMessageImportance.Low);
        }

        static void CleanTempFile(string template)
        {
            var fileInfo = new FileInfo(template);
            if (fileInfo.IsReadOnly)
            {
                return;
            }
            File.Delete(template);
        }

        string TryLocateFile(string fileName, string sourcesDirectory, string targetPath, VersionControlServer vcs)
        {
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "TryLocateFile enter."), BuildMessageImportance.Low);
            try
            {
                if (File.Exists(fileName))
                {
                    return fileName;
                }
            }
            catch { }
            fileName = Path.Combine(sourcesDirectory, fileName);
            try
            {
                if (File.Exists(fileName))
                {
                    return fileName;
                }
            }
            catch { }
            fileName = Path.GetFileName(fileName);
            fileName = Path.Combine(sourcesDirectory, fileName);
            try
            {
                if (File.Exists(fileName))
                {
                    return fileName;
                }
            }
            catch { }
            fileName = Path.GetFileName(fileName);
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "fileName:'{0}'.", fileName), BuildMessageImportance.Low);
            if (vcs.ServerItemExists(targetPath + fileName, ItemType.File))
            {
                fileName = targetPath + fileName;
                this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "fileName found:'{0}'.", fileName), BuildMessageImportance.Low);
            }
            else if (vcs.ServerItemExists(targetPath + "/" + fileName, ItemType.File))
            {
                fileName = targetPath + "/" + fileName;
                this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "fileName found:'{0}'.", fileName), BuildMessageImportance.Low);
            }
            var item = vcs.GetItem(fileName);
            var tempFileName = Path.GetTempFileName();
            item.DownloadFile(tempFileName);
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "TryLocateFile exit: template downloaded to '{0}'.", tempFileName), BuildMessageImportance.Low);

            return tempFileName;
        }

        ITestCaseResult[] GetTestCaseResults(IBuildDetail buildDetail)
        {
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "GetTestCaseResults enter: build: '{0}'.", buildDetail.BuildNumber), BuildMessageImportance.Low);
            if (buildDetail == null) throw new ArgumentNullException("buildDetail");

            var testResultList = new List<ITestCaseResult>();
            var allTestRuns = testManagementService.GetTeamProject(buildDetail.TeamProject).TestRuns.ByBuild(buildDetail.Uri);
            foreach (var testRun in allTestRuns)
            {
                var testResults = testRun.QueryResults();
                testResultList.AddRange(testResults);
            }
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "GetTestCaseResults exit: Test cases found: {0}.", testResultList.Count), BuildMessageImportance.Low);
            return testResultList.ToArray();
        }

        WorkItem[] GetWorkItems(SortedDictionary<int, Changeset> changesetList)
        {
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "GetWorkItems enter."), BuildMessageImportance.Low);
            if (changesetList == null) throw new ArgumentNullException("changesetList");

            var workItems = new SortedDictionary<int, WorkItem>();
            foreach (var changeset in changesetList.Values)
            {
                foreach (var workItem in changeset.WorkItems)
                {
                    workItems.TryAdd(workItem.Id, workItem);
                }
            }
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "GetWorkItems exit: WorkItems found:{0}.", workItems.Count), BuildMessageImportance.Low);
            return workItems.Select(p => p.Value).Reverse().ToArray();
        }

        /// <summary>
        /// Gets the document generation info.
        /// </summary>
        /// <param name="docType">Type of the doc.</param>
        /// <param name="docVersion">The doc version.</param>
        /// <param name="dataContext">The data context.</param>
        /// <param name="fileName">Name of the file.</param>
        /// <param name="useDataBoundControls">if set to <c>true</c> [use data bound controls].</param>
        /// <returns></returns>
        DocumentGenerationInfo GetDocumentGenerationInfo(string docType, string docVersion, object dataContext, string fileName, bool useDataBoundControls)
        {
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "GetDocumentGenerationInfo enter: fileName:'{0}'.", fileName), BuildMessageImportance.Low);

            if (string.IsNullOrWhiteSpace(docType)) throw new ArgumentNullException("docType");
            if (string.IsNullOrWhiteSpace(docVersion)) throw new ArgumentNullException("docVersion");
            if (dataContext == null) throw new ArgumentNullException("dataContext");
            if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentNullException("fileName");
            if (!File.Exists(fileName)) throw new FileNotFoundException(string.Format(string.Format(CultureInfo.CurrentCulture, "File '{0}' was not found.", fileName)));

            DocumentGenerationInfo generationInfo = new DocumentGenerationInfo();
            generationInfo.Metadata = new DocumentMetadata() { DocumentType = docType, DocumentVersion = docVersion };
            generationInfo.DataContext = dataContext;
            generationInfo.TemplateData = File.ReadAllBytes(fileName);
            generationInfo.IsDataBoundControls = useDataBoundControls;

            return generationInfo;
        }

        /// <summary>
        /// Get all checkins from and to for branches related to targetPath.
        /// </summary>
        /// <param name="targetPath">The path to get all checkins for</param>
        /// <param name="level">nested counter</param>
        /// <param name="changesetFrom">Get changes from this changeset</param>
        /// <param name="changesetTo">Get changes upto this changeset</param>
        /// <param name="changesetList">Collected changesets</param>
        /// <param name="targetPathList">Collected targetpaths</param>
        void GetMergeDetails(string targetPath, int level, ChangesetVersionSpec changesetFrom, ChangesetVersionSpec changesetTo, SortedDictionary<int, Changeset> changesetList, List<string> targetPathList)
        {
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "GetMergeDetails enter targetPath:'{0}' level:{1} from:{2} to:{3}.", targetPath, level, changesetFrom, changesetTo), BuildMessageImportance.Low);

            if (string.IsNullOrWhiteSpace(targetPath)) throw new ArgumentNullException("targetPath");
            if (changesetFrom == null) throw new ArgumentNullException("changesetFrom");
            if (changesetTo == null) throw new ArgumentNullException("changesetTo");
            if (changesetList == null) throw new ArgumentNullException("changesetList");
            if (targetPathList == null) throw new ArgumentNullException("targetPathList");

            if (targetPathList.Contains(targetPath))
            {
                return;
            }

            targetPathList.Add(targetPath);
            var details = this.versionControlServer.QueryMergesWithDetails(null, null, 0, targetPath, changesetTo, 0, changesetFrom, changesetTo, RecursionType.Full);
            foreach (var changeset in details.Changesets)
            {
                changesetList.TryAdd(changeset.ChangesetId, changeset);
            }
            var mergeRelations = this.versionControlServer.QueryMergeRelationships(targetPath);
            foreach (var singleMergeRelation in mergeRelations)
            {
                GetMergeDetails(singleMergeRelation.Item, level + 1, changesetFrom, changesetTo, changesetList, targetPathList);
            }
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "GetMergeDetails exit."), BuildMessageImportance.Low);
        }

        IBuildDetail GetLatestBuildWithQuality(IBuildDetail buildDetail, string quality)
        {
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "GetLatestBuildWithQuality enter: build: '{0}' quality:'{1}'.", buildDetail.BuildNumber, quality), BuildMessageImportance.Low);
            var spec = this.buildServer.CreateBuildDetailSpec(buildDetail.BuildDefinition);
            spec.InformationTypes = null;
            spec.Quality = quality;
            spec.QueryDeletedOption = QueryDeletedOption.ExcludeDeleted;
            spec.MaxFinishTime = (buildDetail.FinishTime == DateTime.MinValue || buildDetail.FinishTime == DateTime.MaxValue) ? buildDetail.StartTime.AddSeconds(-1) : buildDetail.FinishTime.AddSeconds(-1);
            spec.QueryOptions = QueryOptions.None;
            spec.Status = BuildStatus.Succeeded;
            spec.QueryOrder = BuildQueryOrder.StartTimeDescending;
            spec.MaxBuildsPerDefinition = 1;

            var result = this.buildServer.QueryBuilds(spec);
            if (result != null && result.Builds.Count() == 1)
            {
                this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "Build '{0}' was found.", result.Builds[0].Uri), BuildMessageImportance.Low);
                return result.Builds[0];
            }
            this.LogBuildMessage(string.Format(CultureInfo.CurrentCulture, "No builds found."), BuildMessageImportance.Low);

            return null;
        }
    }
}